import bisect
from datetime import datetime


#===============================================================================
class JobPluginMount(type):

    #---------------------------------------------------------------------------
    def __init__(cls, name, bases, attrs):
        if not hasattr(cls, 'plugins'):
            # This branch only executes when processing the mount point itself.
            # So, since this is a new plugin type, not an implementation, this
            # class shouldn't be registered as a plugin. Instead, it sets up a
            # list where plugins can be registered later.
            cls.plugins = []
        else:
            # This must be a plugin implementation, which should be registered.
            # Uses bisect to insert in sorted order (see `__cmp__` below).
            # print 'Job plugin %s' % name
            bisect.insort(cls.plugins, cls())



#===============================================================================
class JobBase(object):

    JOB_PRIORITY = 50

    #---------------------------------------------------------------------------
    def __init__(self):
        self.__normalized_limits = self._normalized_limits()
        
        
    #---------------------------------------------------------------------------
    def __cmp__(self, other):
        '''
        Comparator for bisect insertion
        '''
        return cmp(self.JOB_PRIORITY, other.JOB_PRIORITY)

    #---------------------------------------------------------------------------
    def _normalized_limits(self):
        '''
        Normalizes the dictionary -- if any -- returned by the `limits`
        method so that all attribute listings are flatted.
        '''
        lim = self.limits()
        if not lim:
            return None
            
        norm_limits = {}
        for key, values in lim.iteritems():
            norm_values = []
            for v in values:
                if isinstance(v, (list, tuple)):
                    norm_values.extend(range(v[0], v[1] + 1))
                else:
                    norm_values.append(v)
                    
            norm_limits[key] = norm_values
            
        return norm_limits

    #---------------------------------------------------------------------------
    def _should_execute(self, start_dt):
        if not self.__normalized_limits:
            return True
            
        for key, values in self.__normalized_limits.iteritems():
            attr = getattr(start_dt, key, None)
            if attr is None:
                continue
                
            if callable(attr):
                attr = attr()
                
            if attr not in values:
                return False
                
        return True
                
    #---------------------------------------------------------------------------
    def _run(self, start_dt, force=False):
        if force or self._should_execute(start_dt):
            self.execute(start_dt)
            

    #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    # Public methods
    #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

    #---------------------------------------------------------------------------
    def log(self, log_type, message):
        '''
        Standarized logging to stdout.
        '''
        print '[%s %s:%s] %s: %s' % (
            datetime.now().strftime('%Y-%m-%d %X'), 
            self.__class__.__name__,
            self.JOB_TYPE,
            log_type.upper(), 
            message
        )

    #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
    # Override-able methods
    #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

    #---------------------------------------------------------------------------
    def limits(self):
        '''
        Returns a dictionary of finer granularity allowing derived classes to 
        indicate specific allowable thresholds.
        
        The dictionary must be keyed by datetime.datetime instance variable
        names and be an iterable (list or tuple) of specific values. Values must
        be either an integer or 2-tuple of (start, end) range.
        
        Allowable keys: minute, hour, day, month, year, weekday, isoweekday
        
        Examples:
        ---------
        
        - A minute job that only runs every 10 minutes:
        
            def limits(self):
                return dict(
                    minute = [i * 10 for i in range(6)]
                )
                
                
        - An hourly job that runs every hour from 9am - 5pm, Monday thru Friday:
        
            def limits(self):
                return dict(
                    hour = [(9, 17)],
                    weekday = [(0,4)]
                )
        
        
        '''
        return None
        
    #---------------------------------------------------------------------------
    def execute(self, start_dt):
        '''
        Method invoked by the job runner, which *must* be overridden by any
        derived class.
        '''
        raise NotImplementedError(
            "JobBase-derived class must implement 'execute' method"
        )


_job_types = {}


#-------------------------------------------------------------------------------
def job_class_factory(job_type):
    '''
    Build out time threshold base classes, auto-registering them via the 
    `JobPluginMount` metaclass declaration.
    '''
    global _job_types
    
    #===========================================================================
    class JobClass(JobBase):
        __metaclass__ = JobPluginMount
        JOB_TYPE = job_type

    _job_types[job_type] = JobClass
    return JobClass



#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# The following classes serve as the base classes for any cronjob extensions
#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MinuteJob  = job_class_factory('minute')
HourlyJob  = job_class_factory('hourly')
DailyJob   = job_class_factory('daily')
WeeklyJob  = job_class_factory('weekly')
MonthlyJob = job_class_factory('monthly')



#-------------------------------------------------------------------------------
def execute_jobs(job_type, force=False):
    now = datetime.now()
    for job in _job_types[job_type].plugins:
        job._run(now, force=force)


#-------------------------------------------------------------------------------
def make_runner(cls):
    def inner(force=False):
        cls()._run(datetime.now(), force=force)

    return inner

#-------------------------------------------------------------------------------
def test():
    '''
    Quick architectural sanity check 
    '''
    class JobA(HourlyJob):
        def execute(self, dt):
            self.log('test', 'Hello, world: %s' % dt)

    class JobB(HourlyJob):
        JOB_PRIORITY = 40
        def execute(self, dt):
            self.log('test', 'Hello, world: %s' % dt)
    

    class JobC(HourlyJob):
        JOB_PRIORITY = 45
        def execute(self, dt):
            self.log('test', 'Hello, world: %s' % dt)
    
    execute_jobs('hourly')
    
    
################################################################################
if __name__ == '__main__':
    import sys
    if len(sys.argv) == 1:
        test()
    else:
        execute_jobs(sys.argv[1])
        
    

